Skip to content

feat(api): pin the v2 HTTP contract and reject unknown request fields - #242

Merged
rubenhensen merged 4 commits into
mainfrom
feat/api-contracts
Jul 16, 2026
Merged

feat(api): pin the v2 HTTP contract and reject unknown request fields#242
rubenhensen merged 4 commits into
mainfrom
feat/api-contracts

Conversation

@rubenhensen

@rubenhensen rubenhensen commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Closes #209, closes #210 (Phase 1 of EPIC #201) — and goes one step further than pinning the contract: it fixes the leniency the contract would otherwise have had to document as a flaw.

The behavioral change: unknown request fields are rejected

Previously the parser silently ignored unknown fields, so a misspelled v (vaule) widened the disclosure the caller intended — a silent intent-weakening in a key-issuance API. Now:

  • IrmaAuthRequest, DisclosureAttribute, SigningKeyRequest are #[serde(deny_unknown_fields)] → a typo is a 400 naming the field, not a reinterpretation.
  • ConItem's untagged deserialize is replaced with a manual visitor (object vs array discriminates the variants), so clients get serde's precise inner error — ``unknown field vaule, expected one of `t`, `v`, `optional``` — instead of "did not match any variant".
  • Body-deserialization 400s now use the documented {error, message} envelope (previously actix's plain-text default — the spec and reality disagreed).
  • identity::Attribute stays lenient deliberately: it's shared with the bincode wire format, the wasm/FFI seal boundary and response policies, and its values aren't load-bearing in request paths (documented in the spec).

Breaking note: any client sending extra request fields gets 400s after this. All clients are first-party and none send extra fields (pg-js/dotnet/harness send exact shapes); the two PoC deployments should update/verify — the error message tells them exactly what to fix if anything surfaces.

The pinned contract

  • pg-pkg/api-description.yaml (PKG v2 OpenAPI spec #209): the full v2 OpenAPI (all endpoints incl. /statusevents/{token}, both auth modes, error envelope, rate limits, the /v2/{irma|request} alias policy). Lints clean (redocly).
  • pg-pkg/schemas/irma-auth-request.schema.json (Condiscon request JSON Schema + canonical examples #210): the condiscon request as JSON Schema, now additionalProperties: false — schema and parser agree with no caveats.
  • Canonical examples, executable (pg-pkg/tests/contract_examples.rs): every valid/ example must parse, every invalid/ must be rejected — including the new typo vectors (unknown-attribute-field.json, unknown-top-level-field.json). Commentary lives in examples/README.md (JSON comments would now rightly be rejected — the old $comments only parsed thanks to the leniency this PR removes). Covers the legacy flat shape, the website's name disjunction, the optional empty-conjunction convention with the irmamobile#360 ordering workaround, and the classic nesting mistakes.
  • Server-level test: test_unknown_request_field_yields_400_envelope proves the 400 envelope + field-naming end-to-end through the actix extractor.

Tests: pg-core 57 + api tests, pg-pkg 58 + 2 contract tests — all green.

Follow-up (#212, postguard-e2e): validate live PKG responses against the OpenAPI and the examples against the schema, closing the loop schema ↔ parser ↔ running server.

…chema

Two of the three seams every PostGuard client depends on, as machine-checkable
artifacts (EPIC #201, issues #209/#210):

- pg-pkg/api-description.yaml: the full v2 OpenAPI (mirrors cryptify's
  api-description.yaml pattern) — endpoints, auth modes, request/response
  schemas incl. the ConItem condiscon shape, error envelope, rate limits.
  Lints clean (redocly).
- pg-pkg/schemas/irma-auth-request.schema.json + canonical examples: the
  condiscon request pinned as JSON Schema, deliberately faithful to serde
  behavior (unknown fields ignored — documented). Examples cover the legacy
  flat shape, nested disjunctions, the optional empty-conjunction convention
  and its irmamobile#360 ordering workaround, plus the classic nesting
  mistakes as invalid vectors.
- pg-pkg/tests/contract_examples.rs makes the examples EXECUTABLE: every
  valid example must parse as IrmaAuthRequest, every invalid one must be
  rejected — so the examples and the parser cannot drift apart silently.

The e2e harness (postguard-e2e) will additionally validate live responses
against the OpenAPI and the examples against the schema (issue #212).
@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Thanks for the PR, @rubenhensen! I'm on it — kicking off a review of the OpenAPI description and JSON Schema additions (closing #209 and #210) for correctness and rule-compliance, then I'll post a single consolidated review with a clear verdict.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough contract-pinning PR. The executable-examples approach (valid/ must parse, invalid/ must be rejected, with #212 closing the schema ↔ parser ↔ server loop) is a solid way to keep the seam honest. Tests pass (pg-pkg 57 + contract_examples 2) and redocly lints clean apart from the 4xx warnings noted below.

One blocking issue and two smaller notes, all verified against the handler source:

  • bug: /v2/request/jwt/{token} documents 502, but the server returns 503 on the upstream-error path (jwt.rs:21 UpstreamError -> error.rs:83 SERVICE_UNAVAILABLE; there is no 502 anywhere in the PKG). #212 validates live responses against this spec and would trip on the mismatch. The statusevents operation just below already documents 503 for the same error.
  • style: jwt and statusevents both return 400 for a malformed token (each has a passing *_rejects_malformed_token_with_400 test) and 500 on an unreachable upstream; neither is documented. Two of the eight redocly warnings are a genuine missing 4xx, not house-style.
  • nit: the empty-alt-LAST note contradicts the server's own optional:true expansion, which emits the empty conjunction first (start.rs:60).

Rule check (conventional-commit title, closing keywords, cargo-fmt, prose) is clean: the docs(pkg): title satisfies pr-title.yml, Closes #209 / closes #210 link correctly, and EPIC #201 is left open. The 502->503 fix and the missing 400 are small, concrete edits.

Comment thread pg-pkg/api-description.yaml Outdated
schema:
type: string
description: "A compact JWS (three dot-separated segments)."
"502":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong status code. /v2/request/jwt/{token} documents 502 here, but the handler maps this path to 503: jwt.rs:21 does .error_for_status().or(Err(Error::UpstreamError))?, and error.rs:83 maps UpstreamError -> SERVICE_UNAVAILABLE (503). There is no 502 path anywhere in the PKG, and the /statusevents/{token} operation just below correctly documents 503 for the same UpstreamError. Since #212 will validate live PKG responses against this spec, a real unknown-session lookup returns 503 and would mismatch the documented 502. Change this to "503".

description: "The session token from `/v2/request/start`."
schema:
type: string
responses:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documented error responses. Both jwt and statusevents validate {token} with is_valid_session_token and return Error::SessionTokenInvalid -> 400 for a malformed token (jwt.rs:11-13, statusevents.rs:25-27; each has a passing *_rejects_malformed_token_with_400 test), plus Error::Unexpected -> 500 when the upstream is unreachable. Neither 400 nor 500 is documented on these two operations. redocly's operation-4xx-response warnings [7]/[8] point at exactly these operations, so the PR description's claim that the remaining lint warnings are only house-style (license field, localhost server) isn't quite accurate: two of the eight flag a genuine missing 4xx. Please add the 400 (and ideally 500) responses here and on statusevents.

},
"conjunction": {
"title": "Conjunction",
"description": "An AND of attributes. An EMPTY conjunction inside a disjunction marks the whole disjunction as skippable (Yivi's optional-disjunction convention). Ordering note (irmamobile#360): deployed Yivi apps mis-render a disjunction whose empty alternative comes FIRST, so clients currently place the empty alternative LAST; the server accepts either order.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (pre-existing, not introduced by this PR). This note, and the identical one at api-description.yaml:402, tells clients to place the empty alternative LAST as an irmamobile#360 workaround (apps mis-render an empty-first alternative). But the server's own optional:true expansion emits the empty conjunction FIRST: start.rs:60 vec![vec![], vec![ar]], with a test asserting dis[0].is_empty() ("empty alternative first"). If the #360 mis-render is real, the server-generated optional discon trips the same bug. The new contract docs make the contradiction explicit, so it's worth either scoping the note to client-supplied discons or reordering start.rs.

@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Reviewed this and posted a request changes review with 3 inline comments:

  • Bug: pg-pkg/api-description.yaml:190 documents 502 for /v2/request/jwt/{token}, but the handler actually returns 503 (jwt.rs:21error.rs:83), matching what statusevents already documents. Worth fixing before Wire L1 contract tests into the harness/CI #212 starts checking live responses against this spec.
  • Style: both jwt and statusevents return 400 on a malformed token (each covered by a passing test) and 500 on an unreachable upstream, but neither is documented — so a couple of those redocly warnings are a real gap, not just house-style.
  • Nit: the empty-alt-last note in irma-auth-request.schema.json:43 doesn't match start.rs:60, which emits the empty conjunction first. Pre-existing behavior, but worth a reorder or a scoped note now that it's spelled out in the docs.

Everything else — commit title, closing keywords, formatting, prose — checked out clean, and the executable valid/invalid examples plus the #212 loop are a nice way to keep this contract honest. Details in the review: #242 (review)

In a key-issuance request, a silently ignored misspelled field (`vaule` for
`v`, `optioanl` for `optional`) widens the disclosure the caller intended —
intent-weakening must be a 400, not a reinterpretation. Deployment reality
allows going strict directly: all clients are first-party.

- deny_unknown_fields on IrmaAuthRequest, DisclosureAttribute and
  SigningKeyRequest. identity::Attribute stays lenient deliberately: it is
  shared with the bincode wire format, the wasm/FFI seal boundary and
  response policies, and its values are not load-bearing in request paths.
- ConItem's untagged Deserialize replaced with a manual visitor: the JSON
  shape (object vs array) discriminates the variants, so the caller gets the
  precise inner error ("unknown field `vaule`, expected one of `t`, `v`,
  `optional`") instead of "did not match any variant".
- Body-deserialization failures now use the documented {error, message}
  envelope (JsonConfig error_handler) instead of actix's plain-text default.
- Schema/OpenAPI flipped to additionalProperties: false; examples stripped of
  $comment (which only ever parsed thanks to the old leniency — commentary
  moved to examples/README.md); new executable invalid vectors for the typo
  cases; tests assert the 400 names the offending field.
@rubenhensen rubenhensen changed the title docs(pkg): pin the v2 HTTP contract — OpenAPI spec + condiscon JSON Schema feat(api): pin the v2 HTTP contract and reject unknown request fields Jul 16, 2026
…sevents response docs

The contract docs surfaced a contradiction: clients place a discon's empty
alternative LAST to dodge irmamobile#360 (apps mis-render empty-first), but
the server's own optional:true expansion emitted it FIRST — tripping the very
bug the workaround exists for. The expansion now appends it last; either
order is still accepted on input, and the schema/OpenAPI notes say so.

Also per review: /v2/request/jwt/{token} maps upstream error statuses to 503
(UpstreamError), not the previously documented 502, and both jwt and
statusevents additionally return 400 (malformed session token) and 500
(upstream unreachable) — now documented.
@rubenhensen

Copy link
Copy Markdown
Contributor Author

All three addressed, /dobby:

  • 502 → 503 on jwt/{token} — you're right, there is no 502 path; UpstreamError maps to 503. Fixed.
  • Missing 400/500 on jwt and statusevents — both documented now (malformed token → 400 via SessionTokenInvalid, upstream unreachable → 500 via Unexpected). Redocly warnings dropped 8 → 6; the remaining six really are house-style now.
  • The empty-alternative contradiction — the best catch of the three: rather than scoping the doc note, I fixed the server. con_item_to_discon now emits the optional: true empty alternative last, consistent with the client-side irmamobile#360 workaround; either order is still accepted on input. Test renamed and inverted (single_optional_appends_empty_alternative_last), schema/OpenAPI/api.rs wording updated. This means server-expanded optional attributes stop tripping the very app bug the website works around.

@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Thanks for the update — I'm on it. Starting my review of the PR now, focusing on the JWT 502→503 fix, the new 400/500 documentation, and especially the server-side reordering of the empty alternative in con_item_to_discon. I'll follow up with one consolidated review shortly.

The #360 mis-render of empty-first alternatives is fixed and shipped
(irmamobile PRs #557/#558, released in v8.1.0 on 2026-07-10; issue closed
2026-07-14), and postguard-website has already reverted to the conventional
leading-empty order. Prescribing empty-last in the new contract docs — and
flipping the server's optional expansion to match — was codifying a
workaround for a dead bug.

- start.rs: optional expansion back to the conventional empty-first
- schema/OpenAPI/api.rs: ordering documented as presentational with either
  order accepted; #360 kept only as a one-line historical note
- examples: condiscon-name-disjunction.json now mirrors the website's actual
  current request verbatim (leading empty alternative, four name sources,
  optional phone/dob); optional-discon example renamed and reordered
@rubenhensen

Copy link
Copy Markdown
Contributor Author

Correction to my earlier '#360 workaround' change, prompted by review discussion: the workaround is obsolete and is now removed rather than codified.

Verified timeline: fix merged upstream (irmamobile #557/#558, June 12/15), shipped in v8.1.0 (July 10), issue closed July 14 — and postguard-website has already reverted to the conventional leading-empty order in signAttributes.ts.

So the previous commit had it backwards (it moved the server toward the obsolete order). Now: the server's optional: true expansion is back to the conventional empty-first, the docs state that ordering is presentational with either order accepted (with #360 as a one-line historical note), and condiscon-name-disjunction.json mirrors the website's actual current request verbatim. 58 + 2 tests green, lint clean.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid contract-pinning PR — the parser change is well-reasoned (a silently-dropped vaule widening a disclosure is exactly the kind of leniency a key-issuance API should not have), and the executable valid//invalid/ vectors plus the end-to-end 400-envelope test make it hard to regress. The three points from the last round are all addressed, and the empty-alternative fix (emit the empty option last, per irmamobile#360) is correct and now matches the client-side workaround.

Two non-blocking notes before merge:

  1. PR title / release-plz semver. deny_unknown_fields on IrmaAuthRequest/DisclosureAttribute/SigningKeyRequest is a genuine client-observable break — you already say so in the "Breaking note". Per this repo's CLAUDE.md, release-plz decides the bump from the squash-merge subject (the PR title), and a break needs feat(api)!: plus a BREAKING CHANGE: footer; without the ! this ships as a minor bump with no changelog warning. Recommend retitling to feat(api)!: and adding the footer so the release reflects the break.

  2. One stale doc comment on IrmaAuthRequest (inline).

Neither blocks — this is good to merge once the title reflects the break.

Comment thread pg-core/src/api.rs
@@ -45,6 +51,7 @@ pub struct DisclosureAttribute {
/// `[{t,v?,optional?}, ...]` JSON shape still deserialises, because
/// `ConItem` is `#[serde(untagged)]`.
#[derive(Debug, Serialize, Deserialize)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale after this PR: the comment says the flat shape still deserialises because ConItem is #[serde(untagged)], but this PR replaces the untagged deserialize with a manual Deserialize impl (the visit_map/visit_seq visitor below) and drops Deserialize from ConItem's derive. #[serde(untagged)] now governs only Serialize. Anyone debugging a deserialize error will be misdirected — point this at the manual impl instead (which is what actually keeps the flat {t,v?,optional?} shape parsing, via visit_map).

Comment thread pg-core/src/api.rs
/// `ConItem` is `#[serde(untagged)]`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IrmaAuthRequest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deny_unknown_fields (and the ones on DisclosureAttribute and SigningKeyRequest) is a client-observable breaking change: requests that previously succeeded with an extra/misspelled field now 400. That is the right behaviour, but per CLAUDE.md release-plz derives the version bump from the PR title, and feat(api): (no !) ships this as a minor bump with no BREAKING CHANGE: changelog entry. Recommend feat(api)!: + a BREAKING CHANGE: footer so the release matches the Breaking note in the PR body.

@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Nice, all three check out cleanly against HEAD — the 503 mapping, the 400/500 docs on jwt/statusevents, and especially the empty-alternative fix (moving it server-side to fix the root of the irmamobile#360 workaround was the right call). Left two non-blocking notes on postguard#242: the deny_unknown_fields change is a real client-observable break, so per CLAUDE.md this should land as feat(api)!: with a BREAKING CHANGE: footer so release-plz cuts a major bump instead of a silent minor; and there's a stale doc comment on IrmaAuthRequest (api.rs:53) still crediting #[serde(untagged)] for parsing the legacy shape, which now happens via the manual Deserialize impl. Nothing blocking merge otherwise — good to go once the title reflects the break.

@rubenhensen
rubenhensen merged commit 64b8895 into main Jul 16, 2026
37 of 40 checks passed
@dobby-coder
dobby-coder Bot deleted the feat/api-contracts branch July 27, 2026 20:11
dobby-coder Bot added a commit that referenced this pull request Jul 28, 2026
The gate as reviewed promised more than fail-on: ERR delivers. Measured
against oasdiff v1.26.1 on the real spec, ERR silently passed six changes
COMPATIBILITY.md forbids: a changed status code, a dropped response enum
value, a removed and a renamed optional response property, a removed
non-success status, and a removed request parameter.

Two of those (response-non-success-status-removed,
response-property-enum-value-removed) rate ERR but are opt-in, so they only
run when named in include-checks. The rest are WARN, and on this spec that
is most of the "no removing a route or a field" rule: only `status` is ever
`required`, so `key`, `proofStatus`, `pubSignKey` and the rest are optional.
So the gate now runs fail-on: WARN plus those two checks. Of the 31 WARN
checks, response-property-enum-value-added is the only one that fires on
something COMPATIBILITY.md did not already forbid, so that document now
names a new response enum value as non-additive too.

pg-pkg/tests/api_gate.rs is the regression test: it mutates the spec 18
ways, runs the engine with the flags the action's entrypoint builds, and
asserts which mutations the gate stops. It fails on the old settings with
all six gaps named, and skips when oasdiff is not on PATH, which is the
case in CI.

Refs #249, #242. Part of #247 (workstream C).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rubenhensen added a commit that referenced this pull request Jul 29, 2026
* ci: breaking-change gate on the pg-pkg OpenAPI contract (oasdiff)

The gate itself is an oasdiff job in .github/workflows/api-diff.yml, which
the App cannot push, so the YAML ships as a comment on this PR for a
maintainer to apply. What is committed here is the docs half: COMPATIBILITY.md
and CLAUDE.md now say the /v2 rules are still a review rule, name the job and
where its YAML is waiting, and record how to reproduce the verdict locally
(oasdiff v1.26.1, the version the pinned action runs).

Refs #249. Part of #247 (workstream C).

* ci: raise the oasdiff gate to WARN and pin its verdicts in a test

The gate as reviewed promised more than fail-on: ERR delivers. Measured
against oasdiff v1.26.1 on the real spec, ERR silently passed six changes
COMPATIBILITY.md forbids: a changed status code, a dropped response enum
value, a removed and a renamed optional response property, a removed
non-success status, and a removed request parameter.

Two of those (response-non-success-status-removed,
response-property-enum-value-removed) rate ERR but are opt-in, so they only
run when named in include-checks. The rest are WARN, and on this spec that
is most of the "no removing a route or a field" rule: only `status` is ever
`required`, so `key`, `proofStatus`, `pubSignKey` and the rest are optional.
So the gate now runs fail-on: WARN plus those two checks. Of the 31 WARN
checks, response-property-enum-value-added is the only one that fires on
something COMPATIBILITY.md did not already forbid, so that document now
names a new response enum value as non-additive too.

pg-pkg/tests/api_gate.rs is the regression test: it mutates the spec 18
ways, runs the engine with the flags the action's entrypoint builds, and
asserts which mutations the gate stops. It fails on the old settings with
all six gaps named, and skips when oasdiff is not on PATH, which is the
case in CI.

Refs #249, #242. Part of #247 (workstream C).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say the api-diff workflow is not committed yet in the test header

The test's module docs described the gate in the present tense, which is the
same overpromise the reviewed docs had: the workflow YAML is still in a PR
comment waiting for workflows: write. CLAUDE.md and COMPATIBILITY.md already
say so; this file now does too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(pg-pkg): pin that the gate stops a new response enum value

COMPATIBILITY.md gained "adding a value to a response enum" for this gate,
and it was the one rule api_gate.rs did not cover. It holds only at WARN
(measured on oasdiff v1.26.1: exit 0 at ERR+include-checks, exit 1 at
WARN+include-checks), so a revert to fail-on: ERR dropped it with the test
still green -- exactly the drift the test exists to catch. At ERR the test
now reports four wrong verdicts instead of three, the fourth being this one.

* ci: run the pg-pkg OpenAPI breaking-change gate

Dobby's YAML from the #269 comment, applied verbatim (the App cannot push workflows). Landing it here rather than on main so the gate self-tests on this PR, and tightening the two doc lines that described it as not-yet-live.

* test(pg-pkg): pin the workflow's oasdiff inputs against the test's constants

api_gate.rs's header named a hole that only existed while the YAML was
uncommitted: FAIL_ON and INCLUDE_CHECKS are what the test runs oasdiff with,
the workflow step is what CI runs it with, and nothing compared the two. The
verdict test skips on every runner, so a `fail-on` edited down to ERR in the
workflow alone lands green with the whole suite passing. The new test reads
both values back out of the YAML and needs no engine, so it runs in CI.

Also drops the rest of the header's not-committed-yet wording (the merge before
this one did the same for COMPATIBILITY.md), and records in CLAUDE.md that the
remote's workflow-path rejection is per commit diff, so merging a main that
changed build.yml is pushable.

* docs: restore the two CLAUDE.md bullets on the oasdiff gate

The merge in 94e6ed5 hit a conflict where main's two new semver bullets and
this branch's two oasdiff bullets both landed under the Docker one, and took
main's side whole, so the gate this PR adds ended up documented nowhere in
CLAUDE.md. Put them back in place, unchanged, alongside the semver pair.

Also notes on the workflow-push bullet that the App did push a merge carrying
main's build.yml changes (ce0fc59 on this branch), so a sync is worth trying
before treating it as maintainer-only.

* docs: record the wasm browser-test flake and what its error actually means

Hit on this branch: the same docs-only sha passed all three browser jobs at
07:45 and failed at 07:48. The visible error, `missing field 'chunk'`, is the
test runner choking on a truncated webdriver reply, so it reads like a harness
version mismatch; the actual cause is the renderer timeout logged one line
above it. Also notes that only chrome failed and the other two were cancelled
by the fail-fast matrix but reported as failures, and that the App cannot
re-run a workflow.

* test(pg-pkg): pin the oasdiff action ref alongside the gate's inputs

`the_workflow_step_matches_the_pinned_inputs` read `fail-on` and
`include-checks` back out of the workflow but not the action ref, which is
the third thing the pinned verdicts depend on: `breaking@0ab8ad2` is
v0.1.10, whose Dockerfile is `FROM tufin/oasdiff:v1.26.1`, and every
verdict in this file was measured against v1.26.1.

So bumping the action left all three tests green while the verdicts
quietly stopped describing CI -- the same fail-open the `fail-on` pin
exists to prevent, and the file's header claims its verdict is CI's
verdict. Assert the workflow contains the ref too.

Verified by bumping the ref in the workflow: the test fails and names it.

* build: keep api-description.yaml LF on Windows checkouts

pg-pkg/tests/api_gate.rs locates blocks in the spec with multi-line raw-string anchors. rustc normalises CRLF to LF inside raw strings, but fs::read_to_string of the spec keeps whatever git wrote, so on a Windows checkout (core.autocrlf=true is the Git for Windows default) every multi-line anchor misses.

Reproduced rather than argued: converting the spec to CRLF fails every_mutation_still_applies with "anchor is not unique in the spec", i.e. the one gate test that runs without the external engine, so a Windows contributor gets a red suite on a clean tree with a message blaming the spec. CI is ubuntu/macos only, so it never catches this.

Mark the spec -text, which keeps stored bytes equal to checked-out bytes in both directions.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ruben Hensen <ruben.hensen@protonmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Condiscon request JSON Schema + canonical examples PKG v2 OpenAPI spec

1 participant